########################################################################
#  Searx-Qt - Lightweight desktop application for Searx.
#  Copyright (C) 2020-2022  CYBERDEViL
#
#  This file is part of Searx-Qt.
#
#  Searx-Qt is free software: you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation, either version 3 of the License, or
#  (at your option) any later version.
#
#  Searx-Qt is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License
#  along with this program.  If not, see <https://www.gnu.org/licenses/>.
#
########################################################################


from PyQt5.QtWidgets import (
    QWidget,
    QGridLayout,
    QVBoxLayout,
    QHBoxLayout,
    QLineEdit,
    QTextBrowser,
    QCheckBox,
    QLabel,
    QComboBox,
    QFrame,
    QMenu,
    QWidgetAction,
    QShortcut,
    QSpacerItem,
    QStyle,
    QSizePolicy,
    QDialog,
    QListWidget,
    QTableView,
    QSplitter,
    QScrollArea,
    QAbstractItemView,
    QHeaderView,
    QFormLayout,
    QMessageBox
)

from PyQt5.QtCore import (
    pyqtSignal,
    pyqtProperty,
    Qt,
    QByteArray,
    QEvent,
    QPoint,
    QVariant,
    QSize,
    QUrl
)

from PyQt5.QtGui import (
    QDesktopServices,
    QGuiApplication,
    QStandardItem,
    QStandardItemModel,
    QTextDocument,
    QPixmap,
    QImage
)

import html
from collections import OrderedDict

from searxqt.core.customAnchorCmd import AnchorCMD
from searxqt.core.htmlGen import ResultsHtml, FailedResponsesHtml
from searxqt.core.guard import ConsequenceType
from searxqt.core.requests import ErrorType, ImageResult
from searxqt.core.images import ImagesSettings

from searxqt.models.search import (
    SearchStatus,
    UserCategoriesModel,
    CategoriesModel
)
from searxqt.models.instances import EnginesTableModel
from searxqt.widgets.buttons import Button, CheckboxOptionsButton

from searxqt.thread import Thread

from searxqt.translations import _

from searxqt.themes import Themes
from searxqt.core import log


class SearchNavigation(QWidget):
    requestPage = pyqtSignal(int)  # pageno

    def __init__(self, parent=None):
        QWidget.__init__(self, parent=parent)
        layout = QHBoxLayout(self)

        self.prevPageButton = Button("◄", self)
        self.pageNoLabel = QLabel("1", self)
        self.nextPageButton = Button("►", self)

        layout.addWidget(self.prevPageButton, 0, Qt.AlignLeft)
        layout.addWidget(self.pageNoLabel, 0, Qt.AlignCenter)
        layout.addWidget(self.nextPageButton, 0, Qt.AlignRight)

        self.prevPageButton.setEnabled(False)

        self.nextPageButton.clicked.connect(self._nextPage)
        self.prevPageButton.clicked.connect(self._prevPage)

        self.reset()

    def _updateLabel(self):
        self.pageNoLabel.setText(str(self._pageno))

    def _nextPage(self):
        self._pageno += 1
        if self._pageno > 1 and not self.prevPageButton.isEnabled():
            self.prevPageButton.setEnabled(True)
        self._updateLabel()
        self.requestPage.emit(self._pageno)

    def _prevPage(self):
        self._pageno -= 1
        if self._pageno == 1:
            self.prevPageButton.setEnabled(False)
        self.setNextEnabled(True)
        self._updateLabel()
        self.requestPage.emit(self._pageno)

    def reset(self):
        self._pageno = 1
        self.prevPageButton.setEnabled(False)
        self._updateLabel()

    def setNextEnabled(self, state):
        self.nextPageButton.setEnabled(state)


class SearchEngines(CheckboxOptionsButton):
    def __init__(self, searchModel, instancesModel, parent=None):
        """
        @param searchModel: needed for getting and setting current
                            enabled/disabled engines.
        @type searchModel: SearchModel

        @param instancesModel: needed for listing current available
                               engines and update it's current filter
                               to filter out instances without atleast
                               one of the required engine(s).
        @type instancesModel: InstanceModelFilter
        """
        self._instancesModel = instancesModel
        self._searchModel = searchModel
        CheckboxOptionsButton.__init__(
            self,
            labelName=_("Engines"),
            parent=parent
        )

        instancesModel.parentModel().changed.connect(self.reGenerate)

    def updateFilter(self):
        """ Filter out instances that don't support atleast one of the
        enabled engines.
        """
        self._instancesModel.updateKwargs(
            {'engines': self.getCheckedOptionNames()}
        )

    """ Below are re-implementations.
    """
    def getCheckedOptionNames(self):
        """ Should return a list with checked option names. This will
        be used to generate the label.

        @return: should return a list with strings.
        @rtype: list
        """
        return self._searchModel.engines

    def getOptions(self):
        """ Should return a list with options tuple(key, name, state)

        This will be used to generate the options.
        """
        list_ = []
        tmp = []
        for url, instance in self._instancesModel.items():
            for engine in instance.engines:
                if engine.name not in tmp:
                    state = bool(engine.name in self._searchModel.engines)
                    list_.append((engine.name, engine.name, state))
                    tmp.append(engine.name)

        return sorted(list_)

    def optionToggled(self, key, state):
        if state:
            self._searchModel.engines.append(key)
        else:
            self._searchModel.engines.remove(key)

        self.updateFilter()


class CategoryEditor(QDialog):
    def __init__(self, enginesModel, categoriesModel,
                 userCategoriesModel, parent=None):
        QDialog.__init__(self, parent=parent)

        self._categoriesModel = categoriesModel
        self._userCategoriesModel = userCategoriesModel

        self.setWindowTitle(_("Category manager"))

        layout = QHBoxLayout(self)

        # Splitter to horizontal split the categories widget and the engines
        # widget so their width becomes adjustable.
        self.splitter = QSplitter(self)
        self.splitter.setOrientation(Qt.Horizontal)
        layout.addWidget(self.splitter)

        # Categories
        catWidget = QWidget(self.splitter)
        catLayout = QVBoxLayout(catWidget)
        label = QLabel(f"<h2>{_('Categories')}</h2>", self)

        # Categories toolbuttons
        catToolLayout = QHBoxLayout()

        catAddButton = Button("+", self)
        self._catDelButton = Button("-", self)

        catToolLayout.addWidget(catAddButton, 0, Qt.AlignLeft)
        catToolLayout.addWidget(self._catDelButton, 1, Qt.AlignLeft)

        self._categoryListWidget = QListWidget(self)

        catLayout.addWidget(label)
        catLayout.addLayout(catToolLayout)
        catLayout.addWidget(self._categoryListWidget)

        # Engines
        engWidget = QWidget(self.splitter)
        engLayout = QVBoxLayout(engWidget)
        label = QLabel(f"<h2>{_('Engines')}</h2>", self)

        # Engines filter
        filterLayout = QHBoxLayout()

        self._enginesCategoryFilterBox = QComboBox(self)
        self._enginesCategoryFilterBox.addItem(_("All"))

        for key, cat in categoriesModel.items():
            self._enginesCategoryFilterBox.addItem(cat.name)

        filterLayout.addWidget(
            QLabel(_("Category") + ":", self), 1, Qt.AlignRight
        )
        filterLayout.addWidget(
            self._enginesCategoryFilterBox, 0, Qt.AlignRight
        )

        # Engines table
        self._enginesTableView = QTableView(self)
        self._enginesTableView.setAlternatingRowColors(True)
        self._enginesTableView.setSelectionBehavior(
            QAbstractItemView.SelectRows
        )
        self._enginesTableView.setEditTriggers(
            QAbstractItemView.NoEditTriggers
        )
        self._enginesTableView.setSortingEnabled(True)
        self._enginesTableView.setHorizontalScrollMode(
            QAbstractItemView.ScrollPerPixel
        )

        header = self._enginesTableView.horizontalHeader()
        header.setSectionResizeMode(QHeaderView.ResizeToContents)
        header.setSectionsMovable(True)

        self._enginesTableModel = EnginesTableModel(enginesModel, self)

        self._enginesTableView.setModel(self._enginesTableModel)

        engLayout.addWidget(label)
        engLayout.addLayout(filterLayout)
        engLayout.addWidget(self._enginesTableView)

        # Connections
        catAddButton.clicked.connect(self.__addCategoryClicked)
        self._catDelButton.clicked.connect(self.__delCategoryClicked)
        self._categoryListWidget.currentRowChanged.connect(
            self.__currentUserCategoryChanged
        )
        self._enginesCategoryFilterBox.currentIndexChanged.connect(
            self.__enginesCategoryFilterChanged
        )

        self._enginesTableView.setEnabled(False)
        self._catDelButton.setEnabled(False)
        self.__addUserCategories()
        self.__selectFirst()

    def __currentUserCategoryChanged(self, index):
        if index < 0:
            self._enginesTableView.setEnabled(False)
            self._catDelButton.setEnabled(False)
        else:
            self._enginesTableView.setEnabled(True)
            self._catDelButton.setEnabled(True)

        if self._userCategoriesModel:
            key = list(self._userCategoriesModel.keys())[index]
            self._enginesTableModel.setUserModel(
                self._userCategoriesModel[key]
            )

    def __addUserCategories(self):
        for catKey, cat in self._userCategoriesModel.items():
            self._categoryListWidget.addItem(cat.name)

    def __selectFirst(self):
        if self._categoryListWidget.count():
            self._categoryListWidget.setCurrentRow(0)

    def __selectLast(self):
        self._categoryListWidget.setCurrentRow(
            self._categoryListWidget.count() - 1
        )

    def __reloadUserCategories(self):
        self._categoryListWidget.currentRowChanged.disconnect(
            self.__currentUserCategoryChanged
        )
        self._enginesTableModel.setUserModel(None)
        self._categoryListWidget.clear()
        self.__addUserCategories()
        self._categoryListWidget.currentRowChanged.connect(
            self.__currentUserCategoryChanged
        )

    def __addCategoryClicked(self, state):
        dialog = AddUserCategoryDialog(
            self._userCategoriesModel.keys()
        )
        if dialog.exec():
            self._userCategoriesModel.addCategory(
                dialog.name.lower(),
                dialog.name
            )
            self.__reloadUserCategories()
            self.__selectLast()

    def __delCategoryClicked(self, state):
        index = self._categoryListWidget.currentRow()
        key = list(self._userCategoriesModel.keys())[index]

        confirmDialog = QMessageBox()
        confirmDialog.setWindowTitle(
            _("Delete category")
        )
        categoryName = self._userCategoriesModel[key].name
        confirmDialog.setText(_("Are you sure you want to delete the " \
                                f"category `{categoryName}`?"))
        confirmDialog.setStandardButtons(
            QMessageBox.Yes | QMessageBox.No
        )
        confirmDialog.button(QMessageBox.Yes).setText(_("Yes"))
        confirmDialog.button(QMessageBox.No).setText(_("No"))

        if confirmDialog.exec() != QMessageBox.Yes:
            return

        self._userCategoriesModel.removeCategory(key)
        self.__reloadUserCategories()
        self.__selectLast()
        self.__currentUserCategoryChanged(
            self._categoryListWidget.count() - 1
        )

    def __enginesCategoryFilterChanged(self, index):
        if not index:  # All
            self._enginesTableModel.setCatFilter()
        else:
            key = list(self._categoriesModel.keys())[index-1]
            self._enginesTableModel.setCatFilter(key)


class AddUserCategoryDialog(QDialog):
    def __init__(self, existingNames=[], text="", parent=None):
        QDialog.__init__(self, parent=parent)

        self._existingNames = existingNames

        layout = QFormLayout(self)

        label = QLabel(_("Name") + ":")
        self._nameEdit = QLineEdit(self)
        if text:
            self._nameEdit.setText(text)
            self._nameEdit.setPlaceholderText(text)
        else:
            self._nameEdit.setPlaceholderText(_("My category"))

        self._cancelButton = Button(_("Cancel"), self)
        self._addButton = Button(_("Add"), self)
        self._addButton.setEnabled(False)

        # Add stuff to layout
        layout.addRow(label, self._nameEdit)
        layout.addRow(self._cancelButton, self._addButton)

        # Connections
        self._nameEdit.textChanged.connect(self.__inputChanged)
        self._addButton.clicked.connect(self.accept)
        self._cancelButton.clicked.connect(self.reject)

    def __inputChanged(self, text):
        if self.isValid():
            self._addButton.setEnabled(True)
        else:
            self._addButton.setEnabled(False)

    def isValid(self):
        name = self._nameEdit.text().lower()
        if not name:
            return False
        for existingName in self._existingNames:
            if name == existingName.lower():
                return False
        return True

    @property
    def name(self):
        return self._nameEdit.text()


class SearchCategories(CheckboxOptionsButton):
    def __init__(self, categoriesModel, instanceCategoriesModel, enginesModel,
                 userCategoriesModel, parent=None):
        """
        @param categoriesModel: Predefined categories (only avaiable when at
                                least one instance has the category).
        @type categoriesModel: searxqt.models.search.CategoriesModel

        @param instanceCategoriesModel: Some instances define custom search
                                        categories.
        @type instanceCategoriesModel: searxqt.models.search.CategoriesModel

        @param enginesModel:
        @type enginesModel: searxqt.models.instances.EnginesModel

        @param userCategoriesModel:
        @type userCategoriesModel: searxqt.models.search.UserCategoriesModel

        @param parent:
        @type parent: QObject or None
        """
        self._categoriesModel = categoriesModel
        self._instanceCategoriesModel = instanceCategoriesModel
        self._enginesModel = enginesModel
        self._userCategoriesModel = userCategoriesModel

        CheckboxOptionsButton.__init__(
            self,
            labelName=_("Categories"),
            parent=parent
        )

        self._categoriesModel.dataChanged.connect(self.__categoryDataChanged)

    def __categoryDataChanged(self):
        # This happends after CategoriesModel.setData
        self.reGenerate()

    def __openUserCategoryEditor(self):
        window = CategoryEditor(
            self._enginesModel,
            self._categoriesModel,
            self._userCategoriesModel, self)
        window.exec()

    def __userCategoryToggled(self, key, state):
        if state:
            self._userCategoriesModel[key].check()
        else:
            self._userCategoriesModel[key].uncheck()

        self.reGenerate()

    def __instanceCategoryToggled(self, key, state):
        if state:
            self._instanceCategoriesModel[key].check()
        else:
            self._instanceCategoriesModel[key].uncheck()

        self.reGenerate()

    """ Below are re-implementations.
    """
    def addCustomWidgetsTop(self, menu):
        # User specified categories
        menu.addSection(_("Custom"))

        action = QWidgetAction(menu)
        manageCustomButton = Button(_("Manage"), menu)
        action.setDefaultWidget(manageCustomButton)
        menu.addAction(action)

        for catKey, cat in self._userCategoriesModel.items():
            action = QWidgetAction(menu)
            widget = QCheckBox(cat.name, menu)
            widget.setTristate(False)
            widget.setChecked(
                cat.isChecked()
            )
            action.setDefaultWidget(widget)

            widget.stateChanged.connect(
                lambda state, key=catKey:
                    self.__userCategoryToggled(key, state)
            )

            menu.addAction(action)

        # Custom instance specified categories
        menu.addSection(_("Instances"))

        for catKey, cat in self._instanceCategoriesModel.items():
            action = QWidgetAction(menu)
            widget = QCheckBox(cat.name, menu)
            widget.setTristate(False)
            widget.setChecked(cat.isChecked())
            action.setDefaultWidget(widget)

            widget.stateChanged.connect(
                lambda state, key=catKey:
                    self.__instanceCategoryToggled(key, state)
            )

            menu.addAction(action)

        # Predefined Searx categories
        menu.addSection(_("Default"))

        manageCustomButton.clicked.connect(self.__openUserCategoryEditor)

    def hasEnabledCheckedKeys(self):
        """ Same as CheckboxOptionsButton.hasEnabledCheckedKeys(self) but with
        User Categories. Categories don't get enabled/disabled so we can skip
        that check.

        @rtype: bool
        """
        if self._userCategoriesModel.checkedCategories():
            return True
        elif self._instanceCategoriesModel.checkedCategories():
            return True

        return CheckboxOptionsButton.hasEnabledCheckedKeys(self)

    def uncheckAllEnabledKeys(self):
        """ Unchecks all checked keys that are enabled.
        """
        for catKey in self._userCategoriesModel.checkedCategories():
            self._userCategoriesModel[catKey].uncheck()

        for catKey in self._instanceCategoriesModel.checkedCategories():
            self._instanceCategoriesModel[catKey].uncheck()

        CheckboxOptionsButton.uncheckAllEnabledKeys(self)

    def getCheckedOptionNames(self):
        """ Should return a list with checked option names. This will
        be used to generate the label.

        @return: should return a list with strings.
        @rtype: list
        """
        return(
            [
                self._categoriesModel[catKey].name
                for catKey in self._categoriesModel.checkedCategories()
            ] +
            [
                self._instanceCategoriesModel[catKey].name
                for catKey in self._instanceCategoriesModel.checkedCategories()
            ] +
            [
                self._userCategoriesModel[catKey].name
                for catKey in self._userCategoriesModel.checkedCategories()
            ]
        )

    def getOptions(self):
        """ Should return a list with options tuple(key, name, state)

        This will be used to generate the options.
        """
        list_ = []
        for catKey in self._categoriesModel:
            list_.append(
                (
                    catKey,
                    self._categoriesModel[catKey].name,
                    self._categoriesModel[catKey].isChecked()
                )
            )
        return list_

    def optionToggled(self, key, state):
        if state:
            self._categoriesModel[key].check()
        else:
            self._categoriesModel[key].uncheck()


class SearchPeriod(QComboBox):
    def __init__(self, model, parent=None):
        QComboBox.__init__(self, parent=parent)
        self._model = model

        self.setMinimumContentsLength(2)

        for period in model.Periods:
            self.addItem(model.Periods[period], QVariant(period))

        self.currentIndexChanged.connect(self.__indexChanged)

    def __indexChanged(self, index):
        self._model.timeRange = self.currentData()


class SearchLanguage(QComboBox):
    def __init__(self, model, parent=None):
        QComboBox.__init__(self, parent=parent)
        self._model = model
        self._favorites = []

        self.setMinimumContentsLength(2)

        self.__itemModel = QStandardItemModel(self)
        self.setModel(self.__itemModel)

        self.currentIndexChanged.connect(self.__indexChanged)
        self.__itemModel.itemChanged.connect(self.__favCheckChanged)

    def __indexChanged(self, index):
        self._model.lang = self.currentData()

    def __favCheckChanged(self, item):
        lang = item.data(Qt.UserRole)
        index = item.row()
        newIndex = 0
        if item.checkState():
            # Language added to favorites.
            self._favorites.append(lang)
        else:
            # Remove language from favorites.
            langList = list(self._model.Languages.keys())
            newIndex = langList.index(lang)
            self._favorites.remove(lang)

            # Index offset
            for favLang in self._favorites:
                if langList.index(favLang) < newIndex:
                    newIndex -= 1
            newIndex += len(self._favorites)

        self.__itemModel.takeRow(index)
        self.__itemModel.insertRow(newIndex, item)

    def populate(self):
        self.__itemModel.clear()
        for lang in self._model.Languages:
            newItem = QStandardItem(self._model.Languages[lang])
            newItem.setCheckable(True)
            newItem.setFlags(
                Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsEnabled
            )
            newItem.setData(QVariant(lang), Qt.UserRole)

            if lang in self._favorites:
                newItem.setData(Qt.Checked, Qt.CheckStateRole)
                self.__itemModel.insertRow(0, newItem)
                continue

            newItem.setData(Qt.Unchecked, Qt.CheckStateRole)
            self.__itemModel.appendRow(newItem)

    def loadSettings(self, data):
        for lang in data.get('favs', []):
            self._favorites.append(lang)

        self.populate()

        # Find and set the index that matches lang
        self.setCurrentIndex(
            self.findData(QVariant(data.get('lang', '')), Qt.UserRole)
        )

    def saveSettings(self):
        return {
            'lang': str(self.currentData()),
            'favs': self._favorites
        }


class SearchOptionsContainer(QFrame):
    """ Custom QFrame to be able to show or hide certain widgets.
    """
    def __init__(self, searchModel, instancesModel, enginesModel, parent=None):
        """
        @param searchModel:
        @type searchModel: searxqt.models.search.SearchModel

        @param instancesModel:
        @type instancesModel: searxqt.models.instances.InstanceModelFilter

        @param enginesModel:
        @type enginesModel: searxqt.models.instances.EnginesModel

        @param parent:
        @type parent: QObject or None
        """
        QFrame.__init__(self, parent=parent)

        self._enginesModel = enginesModel
        self._searchModel = searchModel

        self._categoriesModel = CategoriesModel()
        self._instanceCategoriesModel = CategoriesModel()
        self._userCategoriesModel = UserCategoriesModel()

        # Backup user checked engines.
        self.__userCheckedBackup = []

        # Keep track of disabled engines (these engines are disabled
        # because they are part of one or more checked categories).
        #
        # @key: engine name (str)
        # @value : list with category keys (str)
        self.__disabledByCat = {}

        layout = QHBoxLayout(self)

        self._widgets = {
            'categories': SearchCategories(
                self._categoriesModel,
                self._instanceCategoriesModel,
                enginesModel,
                self._userCategoriesModel,
                self
            ),
            'engines': SearchEngines(searchModel, instancesModel, self),
            'period': SearchPeriod(searchModel, self),
            'lang': SearchLanguage(searchModel, self)
        }

        for widget in self._widgets.values():
            layout.addWidget(widget, 0, Qt.AlignTop)

        # Keep widgets left aligned.
        spacer = QSpacerItem(
            40, 20, QSizePolicy.MinimumExpanding, QSizePolicy.Minimum
        )
        layout.addItem(spacer)

        # Connections
        self._categoriesModel.stateChanged.connect(
            self.__categoriesStateChanged
        )
        self._instanceCategoriesModel.stateChanged.connect(
            self.__categoriesStateChanged
        )
        self._userCategoriesModel.stateChanged.connect(
            self.__userCategoriesStateChanged
        )
        self._userCategoriesModel.changed.connect(self.__userCategoriesChanged)
        self._userCategoriesModel.removed.connect(self.__userCategoryRemoved)
        self._enginesModel.changed.connect(self.__enginesModelChanged)

    def __enginesModelChanged(self):
        """Settings loaded or data updated
        """
        # Remove deleted engines from __disabledByCat
        for engine in list(self.__disabledByCat.keys()):
            if engine not in self._enginesModel:
                del self.__disabledByCat[engine]
                self._widgets['engines'].setKeyEnabled(engine)

        # Add new categories
        for catKey in self._enginesModel.categories():
            if catKey not in self._categoriesModel:

                name = ""
                if catKey in self._searchModel.categories.types:
                    # Default pre-defined categories are translatable
                    name = self._searchModel.categories.types[catKey][0]
                    self._categoriesModel.addCategory(catKey, name)
                else:
                    name = catKey.capitalize()
                    log.debug(f"Found non default category `{name}`", self)
                    self._instanceCategoriesModel.addCategory(catKey, name)

        # Remove old categories
        for catKey in self._categoriesModel.copy():
            if catKey not in self._enginesModel.categories():
                self._categoriesModel.removeCategory(catKey)

                # Release potentialy checked engines
                self.__processCategoriesStateChange(
                    [engine.name for engine in
                     self._enginesModel.getByCategory(catKey)],
                    catKey,
                    False
                )

        # Remove old instance specific categories
        for catKey in self._instanceCategoriesModel.copy():
            if (catKey not in self._enginesModel.categories() and
                catKey not in self._categoriesModel):
                self._instanceCategoriesModel.removeCategory(catKey)

                # Release potentialy checked engines
                self.__processCategoriesStateChange(
                    [engine.name for engine in
                     self._enginesModel.getByCategory(catKey)],
                    catKey,
                    False
                )

        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __userCategoryRemoved(self, catKey):
        for engineKey, catList in self.__disabledByCat.copy().items():
            if catKey in catList:
                self.__uncheckEngineByCat(catKey, engineKey)

        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __userCategoriesChanged(self, catKey):
        """ When the user edited a existing user-category this should
        check freshly added engines to this category and uncheck engines
        that have been removed from this category.
        """
        if self._userCategoriesModel[catKey].isChecked():
            engines = self._userCategoriesModel[catKey].engines
            # Uncheck removed engines
            for engineKey, categories in self.__disabledByCat.copy().items():
                if catKey in categories:
                    if engineKey not in engines:
                        self.__uncheckEngineByCat(catKey, engineKey)

            # Check newly added engines
            for engineKey in engines:
                if engineKey not in self.__disabledByCat:
                    self.__checkEngineByCat(catKey, engineKey)

            self.__finalizeCategoriesStateChange()

    def __checkEngineByCat(self, catKey, engineKey):
        """ This method handles checking of a engine by a category.

        @param catKey: Category key
        @type catKey: str

        @param engineKey: Engine key
        @type engineKey: str
        """
        if engineKey not in self._searchModel.engines:
            # User did not check this engine so we are going to.
            self._searchModel.engines.append(
                engineKey
            )
        elif(engineKey not in self.__userCheckedBackup and
             not self._widgets['engines'].keyDisabled(engineKey)):
            # User did check this engine, so we backup that.
            self.__userCheckedBackup.append(engineKey)

        if not self._widgets['engines'].keyDisabled(engineKey):
            # Disable the engine from being toggled by the user.
            self._widgets['engines'].setKeyDisabled(engineKey)

        if engineKey not in self.__disabledByCat:
            self.__disabledByCat.update({engineKey: []})

        # Backup that this category is blocking this engine from
        # being toggled by the user.
        self.__disabledByCat[engineKey].append(catKey)

    def __uncheckEngineByCat(self, catKey, engineKey):
        """ This method handles the unchecking of a engine by a category.

        @param catKey: Category key
        @type catKey: str

        @param engineKey: Engine key
        @type engineKey: str
        """
        if engineKey in self.__disabledByCat:
            if catKey in self.__disabledByCat[engineKey]:
                # This category no longer blocks this engine from
                # being edited by the user.
                self.__disabledByCat[engineKey].remove(catKey)

            if not self.__disabledByCat[engineKey]:
                # No category left that blocks this engine from
                # user-toggleing.
                self._widgets['engines'].setKeyEnabled(engineKey)

                self.__disabledByCat.pop(engineKey)

                if engineKey not in self.__userCheckedBackup:
                    # User didn't check this engine, so we can
                    # uncheck it.
                    self._searchModel.engines.remove(
                        engineKey
                    )
                else:
                    # User did check this engine before checking
                    # this category so we won't uncheck it.
                    self.__userCheckedBackup.remove(engineKey)

    def __userCategoriesStateChanged(self, catKey, state):
        """ The user checked or unchecked a user-category.

        @param catKey: Category key
        @type catKey: str

        @param state:Category enabled or disabled (checked or unchecked)
        @type state: bool
        """
        self.__processCategoriesStateChange(
            self._userCategoriesModel[catKey].engines,
            catKey,
            state
        )
        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __categoriesStateChanged(self, catKey, state):
        """ The user checked or unchecked a default-category.

        @param catKey: Category key
        @type catKey: str

        @param state: Category enabled or disabled (checked or unchecked)
        @type state: bool
        """
        self.__processCategoriesStateChange(
            [engine.name for engine in
             self._enginesModel.getByCategory(catKey)],
            catKey,
            state
        )
        self._widgets['categories'].reGenerate()
        self.__finalizeCategoriesStateChange()

    def __processCategoriesStateChange(self, engines, catKey, state):
        """ The user checked or unchecked a category, depending on the
        `state` variable.

        When a category gets checked all the engines in that category
        will be checked and disabled so that the user can't toggle the
        engine.

        On uncheck of a category all engines in that category should be
        re-enabled. And those engines should be unchecked if they weren't
        checked by the user before checking this category.

        @param engines: A list with engineKeys (str) that are part of the
                        category (catKey).
        @type engines: list

        @param catKey: Category key
        @type catKey: str

        @param state: Category enabled or disabled (checked or unchecked)
        @type state: bool
        """
        if state:
            # Category checked.
            for engine in engines:
                self.__checkEngineByCat(catKey, engine)
        else:
            # Category unchecked.
            for engine in engines:
                self.__uncheckEngineByCat(catKey, engine)

    def __finalizeCategoriesStateChange(self):
        # Re-generate the engines label
        self._widgets['engines'].reGenerate()

        # Update the instances filter.
        self._widgets['engines'].updateFilter()

    def saveSettings(self):
        data = {}

        # Store widgets visible state.
        for key, widget in self._widgets.items():
            data.update({
                f"{key}Visible": not widget.isHidden()
            })

        # Store category states and CheckboxOptionsButton states (label
        # expanded or collapsed)
        data.update({
            'userCatModel': self._userCategoriesModel.data(),
            'defaultCatModel': self._categoriesModel.data(),
            'categoriesButton': self._widgets['categories'].saveSettings(),
            'enginesButton': self._widgets['engines'].saveSettings(),
            'language': self._widgets['lang'].saveSettings()
        })

        return data

    def loadSettings(self, data):
        # Set widgets visible or hidden depending on their state.
        for key, widget in self._widgets.items():
            if data.get(f"{key}Visible", True):
                widget.show()
            else:
                widget.hide()

        # Load category states
        self._userCategoriesModel.setData(data.get('userCatModel', {}))
        self._categoriesModel.setData(data.get('defaultCatModel', {}))

        # Load CheckboxOptionsButton states (categories and engines label
        # states, expanded or collapsed.)
        self._widgets['categories'].loadSettings(
            data.get('categoriesButton', {})
        )
        self._widgets['engines'].loadSettings(data.get('enginesButton', {}))

        # Load search language.
        self._widgets['lang'].loadSettings(data.get('language', {}))

    def __checkBoxStateChanged(self, key, state):
        if state:
            self._widgets[key].show()
        else:
            self._widgets[key].hide()

    """ QFrame re-implementations
    """
    def contextMenuEvent(self, event):
        menu = QMenu(self)
        menu.addSection(_("Show / Hide"))

        for key, widget in self._widgets.items():
            action = QWidgetAction(menu)
            checkbox = QCheckBox(key, menu)
            checkbox.setTristate(False)
            checkbox.setChecked(not widget.isHidden())
            action.setDefaultWidget(checkbox)

            checkbox.stateChanged.connect(
                lambda state, key=key:
                    self.__checkBoxStateChanged(key, state)
            )

            menu.addAction(action)

        menu.exec_(self.mapToGlobal(event.pos()))


""" Find text in the search results

    Shortcuts:
     - 'Return' find text.
     - 'Shift+Return' find previous text.
     
"""
class ResultSearcher(QWidget):
    closeRequest = pyqtSignal()

    def __init__(self, resultsContainer, parent):
        QWidget.__init__(self, parent)

        self.__resultsContainer = resultsContainer

        layout = QHBoxLayout(self)

        self.__inputEdit = QLineEdit(self)
        self.__inputEdit.setPlaceholderText(_("Find .."))
        self.__inputEdit.installEventFilter(self)

        self.__caseCheckbox = QCheckBox(_("Case sensitive"), self)
        self.__wholeCheckbox = QCheckBox(_("Whole words"), self)
        nextButton = Button("►", self)
        prevButton = Button("◄", self)
        closeButton = Button("X", self)

        layout.addWidget(self.__inputEdit)
        layout.addWidget(prevButton)
        layout.addWidget(nextButton)
        layout.addWidget(self.__caseCheckbox)
        layout.addWidget(self.__wholeCheckbox)
        layout.addWidget(closeButton)

        closeButton.clicked.connect(self.closeRequest)
        nextButton.clicked.connect(self.__search)
        prevButton.clicked.connect(self.__searchPrev)

    def eventFilter(self, source, event):
        if event.type() == QEvent.KeyPress and source is self.__inputEdit:
            if event.key() == Qt.Key_Return and event.modifiers() == Qt.ShiftModifier:
                self.__search(reverse=True)
            elif event.key() == Qt.Key_Return:
                self.__search()
            elif event.key() == Qt.Key_Escape:
                self.closeRequest.emit()
        return QLineEdit.eventFilter(self, source, event)

    def focusInput(self):
        self.__inputEdit.setFocus()

    def __searchPrev(self):
        self.__search(reverse=True)

    def __search(self, reverse=False):
        text = self.__inputEdit.text()
        flags = QTextDocument.FindFlag(0)

        if reverse:
            flags |= QTextDocument.FindBackward

        if self.__caseCheckbox.isChecked():
            flags |= QTextDocument.FindCaseSensitively

        if self.__wholeCheckbox.isChecked():
            flags |= QTextDocument.FindWholeWords

        self.__resultsContainer.find(text, options=flags)


def makeWrappableText(text):
    """! Transform text with a invisible whitespace character after each
         character so a QLabel does wrap all.

         @note This HACK came from here: https://stackoverflow.com/questions/63587018/qlabel-break-word-if-too-long
    """
    return "\u200b".join(text)


def makeWrappableHtmlText(htmlStr):
    """! Same as 'makeWrappableText' but ignores HTML."""
    insideTag = False
    for i in reversed(range(len(htmlStr) - 1)):
        char = htmlStr[i]

        if char == ">":
            insideTag = True
            continue

        if insideTag:
            if char == "<":
                insideTag = False
            continue

        htmlStr = htmlStr[:i] + "\u200b" + htmlStr[i:]
    return htmlStr


class ImageViewerLabel(QLabel):
    def __init__(self, text="", parent=None):
        QLabel.__init__(self, makeWrappableHtmlText(text), parent)
        self.setWordWrap(True)
        self.setTextInteractionFlags(Qt.TextSelectableByMouse |
                                     Qt.TextSelectableByKeyboard |
                                     Qt.LinksAccessibleByMouse |
                                     Qt.LinksAccessibleByKeyboard)

    def setText(self, text):
        QLabel.setText(self, makeWrappableHtmlText(text))


class ImageViewerLinkLabel(ImageViewerLabel):
    def __init__(self, text="", link="", parent=None):
        text = f"""<a href="{link}">{text}</a>"""
        ImageViewerLabel.__init__(self, text, parent)

    def setText(self, text, link):
        text = f"""<a href="{link}">{text}</a>"""
        ImageViewerLabel.setText(self, text)


class ResizeHandle(QWidget):
    """! Little handle that makes the parent widget resizable by dragging it.
    """
    def __init__(self, parent=None):
        QWidget.__init__(self, parent=parent)
        self.setMinimumHeight(16)
        self.setMinimumWidth(16)
        self.setCursor(Qt.SizeFDiagCursor)
        self.setAttribute(Qt.WA_StyledBackground, True)
        self.resize(16, 16)

    def mousePressEvent(self, event):
        self.__dragStart = event.pos()
        event.accept()

    def mouseMoveEvent(self, event):
        if event.buttons() & Qt.LeftButton:
            parent = self.parent()
            delta = event.pos() - self.__dragStart
            parent.resize(parent.width() + delta.x(), parent.height() + delta.y())
            parent.layout().update()
            event.accept()
            return
        event.ignore()


class ImageViewer(QFrame):
    """! Displays thumbnail with its information.

    @note The window is moveable by dragging the header but also while holding
          `CTRL` and then click + drag.

    @note Keyboard shortcuts:
           - `Escape` close
           - `Right-Arrow` next thumb
           - `Left-Arrow` previous thumb

    @param thumbKey  The thumbnail URL
    @param thumbs    dict object holding thumbs that we use for next/prev
                     thumb.
    """
    def __init__(self, thumbKey, thumbs, parent):
        QFrame.__init__(self, parent, Qt.Dialog)

        self.setWindowTitle(_("Thumbnail viewer"))

        self.__thumbKey = thumbKey
        self.__thumbs = thumbs

        shortcut = QShortcut("Escape", self)
        shortcut.activated.connect(self.close)

        layout = QVBoxLayout(self)

        self.__scrollArea = QScrollArea(self)
        layout.addWidget(self.__scrollArea, 1)

        layout.addWidget(ResizeHandle(self), 0, Qt.AlignRight)

        self.__scrollContentWidget = QWidget(self.__scrollArea)
        layout = QGridLayout(self.__scrollContentWidget)
        self.__scrollArea.setWidget(self.__scrollContentWidget)
        self.__scrollArea.setFrameShape(QFrame.NoFrame)
        self.__scrollArea.setWidgetResizable(True)
        self.__scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.__scrollArea.setAlignment(
            Qt.AlignLeading | Qt.AlignLeft | Qt.AlignTop
        )

        self.__nextButton = Button(">", self)
        self.__prevButton = Button("<", self)
        self.__imgWidget = QLabel(self)
        self.__errorLabel = ImageViewerLabel(parent=self)
        self.__titleLabel = ImageViewerLabel(parent=self)
        self.__imgFmtLabel = ImageViewerLabel(parent=self)
        self.__contentLabel = ImageViewerLabel(parent=self)
        self.__thumbSrcLabel = ImageViewerLinkLabel(parent=self)
        self.__imgSrcLabel = ImageViewerLinkLabel(parent=self)
        self.__sourceLabel = ImageViewerLinkLabel(parent=self)

        layout.addWidget(self.__prevButton        , 1, 0, 1, 1, Qt.AlignVCenter | Qt.AlignLeft)
        layout.addWidget(self.__imgWidget         , 1, 1, 1, 3, Qt.AlignCenter)
        layout.addWidget(self.__nextButton        , 1, 4, 1, 1, Qt.AlignVCenter | Qt.AlignRight)

        layout.addWidget(self.__errorLabel        , 2, 1, 1, 3, Qt.AlignTop | Qt.AlignLeft)

        layout.addWidget(QLabel("Title", self)    , 3, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
        layout.addWidget(self.__titleLabel        , 3, 2, 1, 4, Qt.AlignTop)

        layout.addWidget(QLabel("Format", self)   , 4, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
        layout.addWidget(self.__imgFmtLabel       , 4, 2, 1, 4, Qt.AlignTop)

        layout.addWidget(QLabel("Content", self)  , 5, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
        layout.addWidget(self.__contentLabel      , 5, 2, 1, 4, Qt.AlignTop)

        layout.addWidget(QLabel("Thumbnail", self), 6, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
        layout.addWidget(self.__thumbSrcLabel     , 6, 2, 1, 3, Qt.AlignTop)

        layout.addWidget(QLabel("Image", self)    , 7, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
        layout.addWidget(self.__imgSrcLabel       , 7, 2, 1, 3, Qt.AlignTop)

        layout.addWidget(QLabel("Source", self)   , 8, 1, 1, 1, Qt.AlignTop | Qt.AlignLeft)
        layout.addWidget(self.__sourceLabel       , 8, 2, 1, 3, Qt.AlignTop)

        layout.setColumnStretch(2, 1)
        layout.setRowStretch(1, 1)

        self.__errorLabel.hide()
        self.setThumb(thumbKey)
        self.setFocus()

        self.__nextButton.setShortcut("right")
        self.__prevButton.setShortcut("left")
        self.__nextButton.clicked.connect(self.setNext)
        self.__prevButton.clicked.connect(self.setPrev)

        self.__thumbSrcLabel.setOpenExternalLinks(False)
        self.__thumbSrcLabel.linkActivated.connect(self.__handleAnchorClicked)

        self.__imgSrcLabel.setOpenExternalLinks(False)
        self.__imgSrcLabel.linkActivated.connect(self.__handleAnchorClicked)

        self.__sourceLabel.setOpenExternalLinks(False)
        self.__sourceLabel.linkActivated.connect(self.__handleAnchorClicked)

    def __handleAnchorClicked(self, link):
        url = QUrl(link)
        scheme = url.scheme()

        # Custom command
        if (AnchorCMD.handle(scheme, url.toString())):
            return  # Handled by custom command

        # Let QDesktopServices handle known schemes
        if scheme in ['http', 'https', 'magnet', 'ftp']:
            QDesktopServices.openUrl(url)

    def enterEvent(self, event):
        self.setFocus()

    def hasNext(self):
        keys = list(self.__thumbs.keys())
        currentIndex = keys.index(self.__thumbKey)
        if currentIndex < (len(keys)-1):
            return True
        return False

    def hasPrev(self):
        keys = list(self.__thumbs.keys())
        currentIndex = keys.index(self.__thumbKey)
        if currentIndex > 0:
            return True
        return False

    def setNext(self):
        keys = list(self.__thumbs.keys())
        currentIndex = keys.index(self.__thumbKey)
        self.setThumb(keys[currentIndex + 1])

    def setPrev(self):
        keys = list(self.__thumbs.keys())
        currentIndex = keys.index(self.__thumbKey)
        self.setThumb(keys[currentIndex - 1])

    def clear(self):
        self.__imgWidget.clear()
        self.__titleLabel.clear()
        self.__imgFmtLabel.clear()
        self.__contentLabel.clear()
        self.__thumbSrcLabel.clear()
        self.__imgSrcLabel.clear()
        self.__sourceLabel.clear()
        self.__nextButton.setEnabled(False)
        self.__prevButton.setEnabled(False)

    def setThumb(self, thumbKey):
        self.__thumbKey = thumbKey
        thumbData = self.__thumbs[thumbKey]

        img = thumbData['image']
        res = thumbData['result']

        title=html.escape(res.get('title', ''))
        img_format=html.escape(res.get('img_format', ''))
        url=html.escape(res.get('url', ''))
        img_src=html.escape(res.get('img_src', ''))
        content=html.escape(res.get('content', ''))
        thumbnail_src=html.escape(res.get('thumbnail_src', ''))

        engines = ''
        for e in thumbData.get('engines', []):
            engines += f"{html.escape(e).replace(' ', '-')} "
        engines = engines.rstrip()

        self.__imgWidget.clear()
        self.__errorLabel.hide()
        if thumbData['error'] is not None:
            self.__errorLabel.setText(f"<h3>Error:</h3> {thumbData['error']}")
            self.__errorLabel.show()
        elif img:
            self.__imgWidget.setPixmap(QPixmap.fromImage(img))

        htmlStr = f"""<b>{engines}: {title}</b>"""
        self.__titleLabel.setText(htmlStr)
        self.__imgFmtLabel.setText(img_format)
        self.__contentLabel.setText(content)
        self.__thumbSrcLabel.setText(thumbnail_src, thumbnail_src)
        self.__imgSrcLabel.setText(img_src, img_src)
        self.__sourceLabel.setText(url, url)

        if self.hasNext():
            self.__nextButton.setEnabled(True)
        else:
            self.__nextButton.setEnabled(False)

        if self.hasPrev():
            self.__prevButton.setEnabled(True)
        else:
            self.__prevButton.setEnabled(False)


def fallbackPixVariant(pix):
    """! loadResource() expects a invalid QVariant when the resource failed
         loading. Returning a QVariant(invaidPix) wil result in a loop."""
    if pix.isNull():
        return QVariant()
    return QVariant(pix)


class ResultsBrowser(QTextBrowser):
    """! This is where search results are shown. It has the ability to
         download remote images."""

    ## Image download state: No image downloading threads are active.
    IDLE    = 0
    ## Image download state: Image downloading thread(s) are active.
    LOADING = 1

    ## Signal that is emitted when the image(s) download state has changed.
    ## This will emit `ResultsBrowser.LOADING` when new results have been set
    ## and the first thumbnail downloading thread has been created. It will
    ## emit `ResultsBrowser.IDLE` when the last thread finished, it will not
    ## emit when `cancelAllThreads()` as been called before.
    loadingStateChanged = pyqtSignal(int)

    def __init__(self, imgRequestHandler, *args, **kwargs):
        QTextBrowser.__init__(self, *args, **kwargs)
        self._imgRequestHandler = imgRequestHandler
        self._imgQueue = []
        self._imgThreads = []
        self._imgViewer = None
        self._cancelledThreads = []
        self._status = ResultsBrowser.IDLE

        ## Icons
        self.__iconLoading = ":/default/32_loading.png"
        self.__iconFailed = ":/default/32_failed.png"

        icon = self.style().standardIcon(QStyle.SP_DialogCancelButton)
        self.__pixLoading = icon.pixmap(icon.actualSize(QSize(32, 32)))
        self.__pixFailed = icon.pixmap(icon.actualSize(QSize(32, 32)))

        # {
        #   thumbnail_src: {
        #     'image': QImage,
        #     'result': {},
        #     'error': None
        #   }
        # }
        self._thumbs = OrderedDict()
        self._others = {}

        self.setContextMenuPolicy(Qt.CustomContextMenu)
        self.customContextMenuRequested.connect(self.__showCustomContextMenu)

    @pyqtProperty(str)
    def iconLoading(self):
        return self.__iconLoading

    @iconLoading.setter
    def iconLoading(self, path):
        self.__iconLoading = path
        pix = QPixmap(path)
        if pix.isNull():
            icon = self.style().standardIcon(QStyle.SP_DialogCancelButton)
            self.__pixLoading = icon.pixmap(icon.actualSize(QSize(32, 32)))
        else:
            self.__pixLoading = pix

    @pyqtProperty(str)
    def iconFailed(self):
        return self.__iconFailed

    @iconFailed.setter
    def iconFailed(self, path):
        self.__iconFailed = path
        pix = QPixmap(path)
        if pix.isNull():
            icon = self.style().standardIcon(QStyle.SP_DialogCancelButton)
            self.__pixFailed = icon.pixmap(icon.actualSize(QSize(32, 32)))
        else:
            self.__pixFailed = pix

    @property
    def status(self):
        return self._status

    def reset(self):
        """! Reset content"""
        self.setHtml("")
        self.clearHistory()
        self._thumbs.clear()
        if self._imgViewer is not None:
            self._imgViewer.close()
            self._imgViewer.deleteLater()
            self._imgViewer = None

    def openThumbViewer(self, url):
        if self._imgViewer is None:
            self._imgViewer = ImageViewer(url, self._thumbs, parent=self)
        else:
            self._imgViewer.setThumb(url)
        self._imgViewer.show()

    def getThumbsData(self):
        return self._thumbs

    def getThumbData(self, thumbnail_src):
        return self._thumbs[thumbnail_src]

    def setJsonResult(self, jsonResult):
        """! Set a valid JSON search result"""
        self._thumbs.clear()
        self._others.clear()
        self.setHtml(
            ResultsHtml.create(jsonResult, Themes.htmlCssResults)
        )

        # Create thumb map for later use (ImageViewer)
        for result in jsonResult.get('results', []):
            if result.get('category') != 'images':
                continue

            thumbnail_src = result.get('thumbnail_src')
            if not thumbnail_src:
                continue

            self._thumbs.update({
                thumbnail_src: {
                    'image': None,
                    'result': result,
                    'error': None
                }
            })

    def clear(self):
        """! Overrides QTextBrowser.clear() to also cancell all current
             image downloading threads."""
        self.cancelAllThreads()
        if self._imgViewer is not None:
            self._imgViewer.clear()
        QTextBrowser.clear(self)

    def hasActiveThreads(self):
        """! Are there any image downloading thread(s) active?

        @return <bool>
        """
        return bool(self._imgThreads) or bool(self._cancelledThreads)

    def cancelAllThreads(self):
        """! Cancel all current threads.
        """
        self._imgQueue.clear()
        for thread in self._imgThreads:
            self._cancelledThreads.append(thread)
            thread.requestInterruption()
            thread.finished.disconnect(self._imgThreadFinished)
            thread.finished.connect(self.__cancelledThreadFinished)
        self._imgThreads.clear()
        self._status = ResultsBrowser.IDLE

    def waitOnThreads(self):
        for thread in self._imgThreads:
            thread.wait()
        for thread in self._cancelledThreads:
            thread.wait()

    def __cancelledThreadFinished(self, thread):
        self._cancelledThreads.remove(thread)
        thread.finished.disconnect(self.__cancelledThreadFinished)
        thread.wait()
        thread.deleteLater()

    def __showCustomContextMenu(self, pos):
        # Apparently we need to add the vertical scroll pos ourselves ..
        # .. else the default actions won't work.
        pos2 = pos + QPoint(0, self.verticalScrollBar().value())

        menu = self.createStandardContextMenu(pos2)

        cursor = self.cursorForPosition(pos);
        cf = cursor.charFormat()
        url = cf.toImageFormat().name()

        if not cf.isImageFormat() or url not in self._thumbs:
            menu.exec_(self.mapToGlobal(pos))
            return

        thumb = self._thumbs[url]['result']

        copyMenu = QMenu(_("Copy URL"), self)
        actionCopyThumbUrl = copyMenu.addAction(_("Thumbnail"))
        actionCopyImageUrl = copyMenu.addAction(_("Image"))
        actionCopySourceUrl = copyMenu.addAction(_("Source"))
        menu.addMenu(copyMenu)

        actionRefreshThumb = None
        if self._thumbs[url]['error'] is not None:
            actionRefreshThumb = menu.addAction(_("Re-try thumb download"))

        action = menu.exec_(self.mapToGlobal(pos))

        if action == actionCopyThumbUrl:
            clipboard = QGuiApplication.clipboard()
            clipboard.setText(url)

        elif action == actionCopyImageUrl:
            clipboard = QGuiApplication.clipboard()
            clipboard.setText(thumb.get('img_src', ''))

        elif action == actionCopySourceUrl:
            clipboard = QGuiApplication.clipboard()
            clipboard.setText(thumb.get('source', ''))

        elif self._thumbs[url]['error'] is not None:
            if action == actionRefreshThumb:
                self.getImage(QUrl(url))

    def __getImageThreadFunc(self, url):
        """! Fetch image and create Pixmap from it, this will run from a
             thread."""
        result = self._imgRequestHandler.get(url, ResultType=ImageResult)

        # Assume the thread cancelled
        if url not in self._thumbs and url not in self._others:
            return None

        if result.errorType() != ErrorType.Success:
            log.debug(f"Failed to get image '{result.error()}'", self)
            # Return error
            return result.error()

        img = QImage.fromData(result.content())
        pix = QPixmap.fromImage(img)

        ## @see https://doc.qt.io/qt-6/qt.html#TransformationMode-enum
        transform = Qt.FastTransformation if ImagesSettings.fastTransform else Qt.SmoothTransformation

        # Honor max width/height
        if pix.width() > ImagesSettings.maxWidth:
            pix = pix.scaledToWidth(ImagesSettings.maxWidth, transform)
        if pix.height() > ImagesSettings.maxHeight:
            pix = pix.scaledToHeight(ImagesSettings.maxHeight, transform)

        return (img, pix)

    def getImage(self, url):
        """! Start a new thread or queue for the downloading remote images.

        @param url <QUrl> object
        """
        # Max thread count reached, add to img jobs for future processing
        if len(self._imgThreads) == ImagesSettings.maxThreads:
            self._imgQueue.append(url)
            return fallbackPixVariant(self.__pixLoading)

        urlStr = url.toString()
        if urlStr in self._thumbs:
            self._thumbs[urlStr]['error'] = None
        else:
            self._others.update({urlStr: {'error': None}})

        if not self._imgThreads:
            # This will be the first thread for this result.
            self.loadingStateChanged.emit(ResultsBrowser.LOADING)
            self._status = ResultsBrowser.LOADING

        newThread = Thread(
            self.__getImageThreadFunc,
            args=[urlStr],
            extra=url,
            parent=self
        )
        self._imgThreads.append(newThread)
        newThread.finished.connect(self._imgThreadFinished)
        newThread.start()

        return fallbackPixVariant(self.__pixLoading)

    def _imgThreadFinalize(self, thread):
        """! Cleanup/Remove the given thread and start a new one when the
             queue is not empty.

        @param thread: <Thread>
        """
        self._imgThreads.remove(thread)
        thread.finished.disconnect(self._imgThreadFinished)
        thread.wait()
        thread.deleteLater()

        if self._imgQueue and len(self._imgThreads) < ImagesSettings.maxThreads:
            self.getImage(self._imgQueue.pop(0))
        elif not self._imgThreads:
            # This was the last thread, done loading all thumbnails.
            self.loadingStateChanged.emit(ResultsBrowser.IDLE)
            self._status = ResultsBrowser.IDLE

    def _imgThreadFinished(self, thread):
        """! Add the downloaded image as resource so it will be loaded and
             finalized the thread.

        @param thread: <Thread>
        """
        # Thumb download failed
        if type(thread.result()) is str:
            # Set failed icon
            url = thread.extra
            doc = self.document()
            variant = fallbackPixVariant(self.__pixFailed)
            doc.addResource(QTextDocument.ImageResource, url, variant)
            doc.setTextWidth(doc.idealWidth());  # Trigger stuff to resize/repaint

            # Set error
            self._thumbs[thread.extra.toString()]['error'] = thread.result()

            self._imgThreadFinalize(thread)
            return

        # Thumb download success
        img, pix = thread.result()
        url = thread.extra
        thumbnail_src = url.toString()

        # Backup the full sized thumb, we may need it later in the ImageViewer
        if thumbnail_src in self._thumbs:
            self._thumbs[thumbnail_src]['image'] = img

        doc = self.document()
        doc.addResource(QTextDocument.ImageResource, url, QVariant(pix))
        doc.setTextWidth(doc.idealWidth());  # Trigger stuff to resize/repaint

        self._imgThreadFinalize(thread)

    def loadResource(self, type, name):
        """! Override QTextBrowser.loadResource() to download remote images.

        @see https://doc.qt.io/qt-6/qtextbrowser.html#loadResource
        """
        if type != QTextDocument.ImageResource:
            return QTextBrowser.loadResource(self, type, name)

        return self.getImage(name)


class SearchContainer(QWidget):
    def __init__(self, searchModel, imgRequestHandler, instancesModel,
                 instanceSelecter, enginesModel, guard, parent=None):
        """
        @type searchModel: models.search.SearchModel
        @type instancesModel: models.instances.InstancesModelFilter
        @type instanceSelecter: models.instances.InstanceSelecterModel
        @type enginesModel: models.instances.EnginesModel
        @type guard: core.guard.Guard
        """

        QWidget.__init__(self, parent=parent)
        layout = QVBoxLayout(self)

        self._model = searchModel
        self._instancesModel = instancesModel
        self._instanceSelecter = instanceSelecter
        self._guard = guard
        self._searchThread = None

        # Maximum other instances to try on fail.
        self._maxSearchFailCount = 10

        # Set `_useFallback` to True to try another instance when the
        # search failed somehow or set to False to try same instance or
        # for pagination which also should use the same instance.
        self._useFallback = True

        # `_fallbackActive` should be False when a fresh list of fallback
        # instances should be picked on failed search and should be True
        # when search(es) fail and `_fallbackInstancesQueue` is beeing
        # used.
        self._fallbackActive = False

        # Used to store errors when fallback is active so they can be listed
        # to the user when even the fallback fails.
        self._fallbackErrors = []

        # Every first request that has `_useFallback` set to True will
        # use this list as a resource for next instance(s) to try until
        # it is out of instance url's. Also on the first request this
        # list will be cleared and filled again with `_maxSearchFailCount`
        # of random instances.
        self._fallbackInstancesQueue = []

        # Set to True to break out of the fallback loop.
        # This is used for the Stop action.
        self._breakFallback = False

        searchLayout = QHBoxLayout()
        layout.addLayout(searchLayout)

        # -- Start search bar
        self.queryEdit = QLineEdit(self)
        self.queryEdit.setPlaceholderText(_("Search for .."))
        searchLayout.addWidget(self.queryEdit)

        self.searchButton = Button(_("Searx"), self)
        self.searchButton.setToolTip(_("Preform search."))
        searchLayout.addWidget(self.searchButton)

        self.reloadButton = Button("♻", self)
        self.reloadButton.setToolTip(_("Reload"))
        searchLayout.addWidget(self.reloadButton)

        self.randomButton = Button("⤳", self)
        self.randomButton.setToolTip(_(
            "Search with random instance.\n"
            "(Obsolete when 'Random Every is checked')"
        ))
        searchLayout.addWidget(self.randomButton)

        rightLayout = QVBoxLayout()
        rightLayout.setSpacing(0)
        searchLayout.addLayout(rightLayout)

        self._fallbackCheck = QCheckBox(_("Fallback"), self)
        self._fallbackCheck.setToolTip(_("Try random other instance on fail."))
        rightLayout.addWidget(self._fallbackCheck)

        self._randomCheckEvery = QCheckBox(_("Random every"), self)
        self._randomCheckEvery.setToolTip(_("Pick a random instance for "
                                          "every request."))
        rightLayout.addWidget(self._randomCheckEvery)
        # -- End search bar

        # -- Start splitter
        self.splitter = QSplitter(self)
        self.splitter.setOrientation(Qt.Vertical)
        layout.addWidget(self.splitter)

        # ---- Start search options toolbar
        self._optionsContainer = SearchOptionsContainer(
            searchModel, instancesModel, enginesModel, self.splitter
        )
        # ---- End search options toolbar

        # --- Start search results container
        searchBottomWidget = QWidget(self.splitter)
        self.searchBottomWidgetLayout = QVBoxLayout(searchBottomWidget)

        self.resultsContainer = ResultsBrowser(imgRequestHandler, self)
        self.resultsContainer.setOpenLinks(False)
        self.resultsContainer.setOpenExternalLinks(False)
        self.resultsContainer.setLineWrapMode(1)
        self.searchBottomWidgetLayout.addWidget(self.resultsContainer)

        self.navBar = SearchNavigation(self)
        self.navBar.setEnabled(False)
        self.searchBottomWidgetLayout.addWidget(self.navBar)
        # --- End search results container
        # -- End splitter

        # Find text
        self.__findShortcut = QShortcut('Ctrl+F', self);
        self.__findShortcut.activated.connect(self.__openFind);
        self.__resultsSearcher = None

        self.queryEdit.textChanged.connect(self.__queryChanged)
        self._model.statusChanged.connect(self.__searchStatusChanged)
        self._model.optionsChanged.connect(self.__searchOptionsChanged)
        self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)
        self._randomCheckEvery.stateChanged.connect(
                                self.__randomEveryRequestChanged)
        self._instanceSelecter.instanceChanged.connect(
                                self.__instanceChanged)

        self.queryEdit.returnPressed.connect(self.__searchButtonClicked)
        self.searchButton.clicked.connect(self.__searchButtonClicked)
        self.reloadButton.clicked.connect(self.__reloadButtonClicked)
        self.randomButton.clicked.connect(self.__randomSearchButtonClicked)
        self.navBar.requestPage.connect(self.__navBarRequest)
        self.resultsContainer.anchorClicked.connect(self.__handleAnchorClicked)
        self.resultsContainer.loadingStateChanged.connect(self.__loadingStateChanged)

        self.__queryChanged("")

    def isBusy(self):
        return bool(self._searchThread is not None or
                    self.resultsContainer.hasActiveThreads())

    def cancelAll(self):
        self._breakFallback = True
        if self._searchThread:
            self._searchThread.wait()
        self.resultsContainer.cancelAllThreads()
        self.resultsContainer.waitOnThreads()

    def reset(self):
        self._model.reset()
        self.resultsContainer.reset()

    def __openFind(self):
        if self.__resultsSearcher is None:
            self.__resultsSearcher = ResultSearcher(self.resultsContainer, self)
            self.searchBottomWidgetLayout.insertWidget(1, self.__resultsSearcher)
            self.__resultsSearcher.closeRequest.connect(self.__closeFind)

        self.__resultsSearcher.focusInput()

    def __closeFind(self):
        self.searchBottomWidgetLayout.removeWidget(self.__resultsSearcher)
        self.__resultsSearcher.deleteLater()
        self.__resultsSearcher = None

    def __handleAnchorClicked(self, url):
        scheme = url.scheme()

        # Internal from sugestions/corrections
        if scheme == 'search':
            self.queryEdit.setText(url.path())
            self._newSearch(self._instanceSelecter.currentUrl)
            return

        if scheme == 'thumb':
            fixedUrl = url.toString()[8:].replace("//", "://", 1)
            self.resultsContainer.openThumbViewer(fixedUrl)
            return

        # Custom command
        if (AnchorCMD.handle(scheme, url.toString())):
            return  # Handled by custom command

        # Let QDesktopServices handle known schemes
        if scheme in ['http', 'https', 'magnet', 'ftp']:
            QDesktopServices.openUrl(url)

        # Unknown schemes are not doing anything .. :-)

    def __searchButtonClicked(self, checked=0):
        # Set to use fallback
        self._useFallback = self._model.useFallback

        if (self._model.randomEvery or
                (self._useFallback and
                    not self._instanceSelecter.currentUrl)):
            self._instanceSelecter.randomInstance()

        self._resetPagination()
        self._newSearch(self._instanceSelecter.currentUrl)

    def __stopButtonClicked(self):
        self._breakFallback = True
        self.searchButton.setEnabled(False)

    def __stopImagesButtonClicked(self):
        self.resultsContainer.cancelAllThreads()
        self.searchButton.setText(_("Search"))
        self.searchButton.clicked.disconnect(self.__stopImagesButtonClicked)
        self.searchButton.clicked.connect(self.__searchButtonClicked)

    def __reloadButtonClicked(self):
        self._useFallback = False
        self._newSearch(self._instanceSelecter.currentUrl)

    def __randomSearchButtonClicked(self):
        self._useFallback = self._model.useFallback
        self._instanceSelecter.randomInstance()
        self._newSearch(self._instanceSelecter.currentUrl)

    def __navBarRequest(self, pageNo):
        self._useFallback = False
        self._model.pageno = pageNo
        self._newSearch(self._instanceSelecter.currentUrl)

    def _resetPagination(self):
        self.navBar.reset()
        self._model.pageno = 1

    def __instanceChanged(self):
        self._resetPagination()

    def __searchOptionsChanged(self):
        """ From the model (on load settings)
        """
        self._randomCheckEvery.stateChanged.disconnect(
                                self.__randomEveryRequestChanged)
        self._fallbackCheck.stateChanged.disconnect(
                                self.__useFallbackChanged)

        self._randomCheckEvery.setChecked(self._model.randomEvery)
        self._fallbackCheck.setChecked(self._model.useFallback)

        self.__handleRandomButtonState()

        self._randomCheckEvery.stateChanged.connect(
                                self.__randomEveryRequestChanged)
        self._fallbackCheck.stateChanged.connect(self.__useFallbackChanged)

    def __handleRandomButtonState(self):
        """ Hides or shows the 'Random search button'.
        We don't need the button when the model it's randomEvery is True.
        """
        if self._model.randomEvery:
            self.randomButton.hide()
        else:
            self.randomButton.show()

    def __randomEveryRequestChanged(self, state):
        """ From the checkbox
        """
        self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
        self._model.randomEvery = bool(state)
        self.__handleRandomButtonState()
        self._model.optionsChanged.connect(self.__searchOptionsChanged)

    def __useFallbackChanged(self, state):
        """ From the checkbox
        """
        self._model.optionsChanged.disconnect(self.__searchOptionsChanged)
        self._model.useFallback = bool(state)
        self._model.optionsChanged.connect(self.__searchOptionsChanged)

    def __queryChanged(self, q):
        if self._model.status() == SearchStatus.Busy:
            return

        if q:
            self.searchButton.setEnabled(True)
            self.reloadButton.setEnabled(True)
            self.randomButton.setEnabled(True)
        else:
            self.searchButton.setEnabled(False)
            self.reloadButton.setEnabled(False)
            self.randomButton.setEnabled(False)

    def _setOptionsState(self, state=True):
        if self._useFallback:
            if state:
                # Search stopped
                self.searchButton.setText(_("Search"))
                self.searchButton.clicked.disconnect(
                    self.__stopButtonClicked
                )
                self.searchButton.clicked.connect(self.__searchButtonClicked)
                self.searchButton.setEnabled(True)
            else:
                # Searching
                self.searchButton.setText(_("Stop"))
                self.searchButton.clicked.disconnect(
                    self.__searchButtonClicked
                )
                self.searchButton.clicked.connect(self.__stopButtonClicked)
        else:
            self.searchButton.setEnabled(state)

        self.reloadButton.setEnabled(state)
        self.randomButton.setEnabled(state)
        self._randomCheckEvery.setEnabled(state)
        self._fallbackCheck.setEnabled(state)
        self.queryEdit.setEnabled(state)
        self._optionsContainer.setEnabled(state)

    def __searchStatusChanged(self, status):
        if status == SearchStatus.Busy:
            self._setOptionsState(False)

        elif status == SearchStatus.Done:
            self._setOptionsState()

    def __loadingStateChanged(self, status):
        if status == ResultsBrowser.IDLE:
            self.searchButton.setText(_("Search"))
            self.searchButton.clicked.disconnect(self.__stopImagesButtonClicked)
            self.searchButton.clicked.connect(self.__searchButtonClicked)
        else:
            self.searchButton.setText(_("Stop"))
            self.searchButton.clicked.disconnect(self.__searchButtonClicked)
            self.searchButton.clicked.connect(self.__stopImagesButtonClicked)

    @property
    def query(self): return self.queryEdit.text()

    def _newSearch(self, url, query=''):
        if self.resultsContainer.status == ResultsBrowser.LOADING:
            self.__stopImagesButtonClicked()
        self.resultsContainer.clear()
        self._search(url, query)

    def _search(self, url, query=''):
        if self._searchThread:
            return

        if not query:
            query = self.query

        if not query:
            self.resultsContainer.setHtml(_("Please enter a search query."))
            return

        if not url:
            self.resultsContainer.setHtml(_("Please select a instance first."))
            return

        self.navBar.setEnabled(False)

        self._model.url = url
        self._model.query = query

        self._searchThread = Thread(
            self._model.search,
            args=[self._model],
            parent=self
        )
        self._searchThread.finished.connect(self._searchFinished)
        self._searchThread.start()

    def _searchFailed(self, result):
        currentUrl = self._instanceSelecter.currentUrl  # backup

        # Don't go further on proxy errors.
        #  - Guard should not handle proxy errors.
        #  - Fallback should be disabled for proxy errors.
        if result.errorType() == ErrorType.ProxyError:
            self._breakFallback = False
            self.resultsContainer.setHtml(
                FailedResponsesHtml.create([result], Themes.htmlCssFail)
            )
            return

        if self._guard.isEnabled():
            # See if the Guard has any consequence for this instance.
            consequence = self._guard.getConsequence(currentUrl)
            if consequence:
                # Apply the consequence.
                if consequence.type == ConsequenceType.Blacklist:
                    # Blacklist the instance.
                    self._instancesModel.putInstanceOnBlacklist(
                        currentUrl,
                        reason=result.error()
                    )
                else:
                    # Put the instance on a timeout.
                    self._instancesModel.putInstanceOnTimeout(
                        currentUrl,
                        duration=consequence.duration,
                        reason=result.error()
                    )
                self._instancesModel.apply()  # Apply the changed filter.

        if self._useFallback:  # Re-try another instance
            if self._breakFallback:  # Stop button pressed
                self._breakFallback = False
                self._fallbackActive = False
                self._fallbackErrors.append(result)
                self.resultsContainer.setHtml(
                    FailedResponsesHtml.create(
                        self._fallbackErrors,
                        f"{_('Max fail count reached!')} " \
                        f"({self._maxSearchFailCount})",
                        Themes.htmlCssFail)
                )
                self._fallbackErrors.clear()
                return

            if not self._fallbackActive:
                # Get new list with instances to try same request.
                self._fallbackActive = True
                self._fallbackErrors.clear()
                self._fallbackInstancesQueue.clear()
                self._fallbackInstancesQueue = (
                        self._instanceSelecter.getRandomInstances(
                                    amount=self._maxSearchFailCount))

            if not self._fallbackInstancesQueue:
                self.resultsContainer.setHtml(
                    FailedResponsesHtml.create(
                        self._fallbackErrors,
                        f"{_('Max fail count reached!')} " \
                        f"({self._maxSearchFailCount})",
                        Themes.htmlCssFail)
                )
                self._fallbackActive = False
                self._fallbackErrors.clear()
                return

            # Append current error to error list
            self._fallbackErrors.append(result)

            # Set next instance url to try.
            self._instanceSelecter.currentUrl = (
                    self._fallbackInstancesQueue.pop(0))

            self._search(self._instanceSelecter.currentUrl)
            return

        if self._model.pageno > 1:
            self.navBar.setEnabled(True)
            self.navBar.setNextEnabled(False)

        self.resultsContainer.setHtml(
            FailedResponsesHtml.create([result], _("Search request failed."), Themes.htmlCssFail)
        )

    def _searchFinished(self):
        result = self._searchThread.result()
        self._clearSearchThread()

        # Guard
        if self._guard.isEnabled():
            currentUrl = self._instanceSelecter.currentUrl

            # Report the search result to Guard.
            self._guard.reportSearchResult(currentUrl, result)

        if not bool(result):  # Failed
            self._searchFailed(result)
            return

        self._fallbackActive = False

        self.resultsContainer.setJsonResult(result.json())
        self.navBar.setEnabled(True)
        self.navBar.setNextEnabled(True)

    def _clearSearchThread(self):
        self._searchThread.finished.disconnect(self._searchFinished)

        # Wait before deleting because the `finished` signal is emited
        # from the thread itself, so this method could be called before the
        # thread is actually finished and result in a crash.
        self._searchThread.wait()

        self._searchThread.deleteLater()
        self._searchThread = None

    def saveSettings(self):
        return {
            'searchOptions': self._optionsContainer.saveSettings(),
            'splitterState': self.splitter.saveState()
        }

    def loadSettings(self, data):
        self.queryEdit.setText("")

        self._optionsContainer.loadSettings(
            data.get('searchOptions', {})
        )

        self.splitter.restoreState(
            data.get('splitterState', QByteArray())
        )
